// This Pine Script® code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © AlgoAlpha

//@version=6
indicator("Support & Resistance Ultra Toolkit [AlgoAlpha]", "AlgoAlpha - S&R Ultra", overlay = true, max_boxes_count = 500, max_lines_count = 500, max_labels_count = 500, max_bars_back = 5000)

// Inputs
generalGR = "General"
showSignals = input.bool(true, "Signals", inline = "display", group = generalGR)
showZoneLabels = input.bool(true, "Zone Labels", inline = "display", group = generalGR, tooltip = "Signals shows rejection, wick-sweep, and breakout triangles. Zone Labels shows live data beside active zones. These switches do not change alerts.")
showDashboard = input.bool(true, "Dashboard", inline = "dashboard", group = generalGR)
panelPosition = input.string("Top Right", "", options = ["Top Right", "Bottom Right", "Bottom Left"], inline = "dashboard", group = generalGR, active = showDashboard, display = display.none, tooltip = "Show the zone table at the selected corner. Historical break estimates need at least 20 completed samples. They are not guaranteed forecasts.")

calcGR = "Calculations"
mode = input.string("Pivots", "Detection Mode", options = ["Pivots", "Donchian"], group = calcGR, display = display.none, tooltip = "Pivots matches two nearby swing highs or lows. Donchian creates zones when a channel edge stays flat.")
timeHorizon = input.int(7, "Pivot Length", minval = 1, maxval = 100, inline = "pivots", group = calcGR, active = mode == "Pivots", display = display.none)
pairBars = input.int(100, "Max Gap", minval = 1, maxval = 2000, inline = "pivots", group = calcGR, active = mode == "Pivots", display = display.none, tooltip = "Pivot Length sets the bars on each side of a swing. Max Gap sets the maximum bars between two matching swings. Both use the zone source timeframe.")
matchMult = input.float(1.0, "Match Tolerance", minval = 0.01, step = 0.05, group = calcGR, active = mode == "Pivots", display = display.none, tooltip = "Higher values allow wider price gaps between matching swings. The limit is this value times the 40-bar standard deviation of source closes.")
donchLength = input.int(30, "Channel Length", minval = 2, maxval = 1000, inline = "donchian", group = calcGR, active = mode == "Donchian", display = display.none)
flatBars = input.int(5, "Flat Bars", minval = 1, maxval = 1000, inline = "donchian", group = calcGR, active = mode == "Donchian", display = display.none, tooltip = "Channel Length sets the high/low lookback. Flat Bars sets how many consecutive source-bar changes must leave the edge unchanged before one zone is created. Zone depth is set automatically.")
expiryBars = input.int(500, "Zone Expiry", minval = 1, maxval = 5000, inline = "life", group = calcGR, display = display.none)
stopOnBreak = input.bool(true, "Stop on Break", inline = "life", group = calcGR, tooltip = "Zone Expiry is the number of chart bars after creation. Stop on Break also stops a zone at the first close through its outer edge. Otherwise, it extends until expiry. Stopped boxes remain on the chart.")
useMTF = input.bool(false, "Higher Timeframe", inline = "mtf", group = calcGR)
zoneTF = input.timeframe("60", "", inline = "mtf", group = calcGR, active = useMTF, display = display.none, tooltip = "Use this timeframe for zone creation only. Select the chart timeframe or higher. Higher-timeframe zones use confirmed source bars. Signals, breaks, age, and expiry still use chart bars.")

appearanceGR = "Appearance"
bullCol = input.color(#00ffbb, "Colours", inline = "colours", group = appearanceGR)
bearCol = input.color(#ff1100, "", inline = "colours", group = appearanceGR, tooltip = "First: support and bullish signals. Second: resistance and bearish signals. Zone shading and solid borders use fixed transparency.")

// Internal Defaults
volLength = 40
donchDepth = 0.5
historyBars = 5000
expiryWarning = 10
minSamples = 20
highRiskThreshold = 70.0
zoneFillTransparency = 60
zoneBorderTransparency = 77
labelOffset = 3
markerMult = 0.15

// Types
type PivotPoint
    int idx
    int stamp
    float price
    float o
    float h
    float l
    float c

type Zone
    box bx
    line mid
    label lbl
    float top
    float bottom
    bool isSupport
    int born
    bool extending = true
    int breakAge = na
    int tests = 0
    int rejects = 0
    int sweeps = 0
    float touchVolume = 0.0
    bool wasTouch = false
    bool visitOpen = false
    bool rejectedVisit = false
    bool sweptVisit = false
    bool warned = false

// State
var zones = array.new<Zone>()
var riskSamples = array.new_int((expiryBars + 1) * 2, 0)
var riskBreaks = array.new_int((expiryBars + 1) * 2, 0)
var matureCursor = 0
var lastSourceStamp = int(na)
var firstAllowedBar = math.max(0, last_bar_index - historyBars + 1)

panelAnchor = switch panelPosition
    "Bottom Right" => position.bottom_right
    "Bottom Left" => position.bottom_left
    => position.top_right

textSize = size.small

var dashboard = showDashboard ? table.new(panelAnchor, 3, 16, bgcolor = color.new(chart.bg_color, 5), frame_color = color.new(chart.fg_color, 80), frame_width = 1, border_color = color.new(chart.fg_color, 92), border_width = 1) : na

// Pivot Zone Helpers
edgeDistance(isResistance, edge, value) =>
    isResistance ? edge - value : value - edge

includeDistance(currentMax, isResistance, edge, value, tolerance) =>
    distance = edgeDistance(isResistance, edge, value)
    distance >= 0.0 and distance <= tolerance ? math.max(currentMax, distance) : currentMax

zoneThickness(PivotPoint p1, PivotPoint p2, isResistance, tolerance) =>
    edge = isResistance ? math.max(p1.h, p2.h) : math.min(p1.l, p2.l)
    maxDist = math.abs(p1.price - p2.price)
    maxDist := includeDistance(maxDist, isResistance, edge, p1.o, tolerance)
    maxDist := includeDistance(maxDist, isResistance, edge, p1.h, tolerance)
    maxDist := includeDistance(maxDist, isResistance, edge, p1.l, tolerance)
    maxDist := includeDistance(maxDist, isResistance, edge, p1.c, tolerance)
    maxDist := includeDistance(maxDist, isResistance, edge, p2.o, tolerance)
    maxDist := includeDistance(maxDist, isResistance, edge, p2.h, tolerance)
    maxDist := includeDistance(maxDist, isResistance, edge, p2.l, tolerance)
    maxDist := includeDistance(maxDist, isResistance, edge, p2.c, tolerance)
    math.max(maxDist, syminfo.mintick)

zoneBounds(PivotPoint p1, PivotPoint p2, isResistance, tolerance) =>
    edge = isResistance ? math.max(p1.h, p2.h) : math.min(p1.l, p2.l)
    thickness = zoneThickness(p1, p2, isResistance, tolerance)
    top = isResistance ? edge : edge + thickness
    bottom = isResistance ? edge - thickness : edge
    [math.round_to_mintick(top), math.round_to_mintick(bottom)]

pairPivot(array<PivotPoint> memory, PivotPoint current, isResistance, tolerance) =>
    top = float(na)
    bottom = float(na)
    origin = int(na)
    while memory.size() > 0
        oldest = memory.last()
        if current.idx - oldest.idx > pairBars
            memory.pop()
        else
            break
    if not na(tolerance) and memory.size() > 0
        for i = 0 to memory.size() - 1
            previous = memory.get(i)
            gap = current.idx - previous.idx
            if gap > 0 and gap <= pairBars and math.abs(current.price - previous.price) <= tolerance
                [matchedTop, matchedBottom] = zoneBounds(previous, current, isResistance, tolerance)
                top := matchedTop
                bottom := matchedBottom
                origin := previous.stamp
                break
    memory.unshift(current)
    [top, bottom, origin]

// Source-Timeframe Detection
sourceZones() =>
    ph = ta.pivothigh(high, timeHorizon, timeHorizon)
    pl = ta.pivotlow(low, timeHorizon, timeHorizon)
    volatility = ta.stdev(close, volLength)
    tolerance = math.max(volatility * matchMult, syminfo.mintick)
    channelHigh = ta.highest(high, donchLength)
    channelLow = ta.lowest(low, donchLength)
    var highs = array.new<PivotPoint>()
    var lows = array.new<PivotPoint>()
    var highFlat = 0
    var lowFlat = 0
    highFlat := bar_index >= donchLength and channelHigh == channelHigh[1] ? math.min(highFlat + 1, flatBars + 1) : 0
    lowFlat := bar_index >= donchLength and channelLow == channelLow[1] ? math.min(lowFlat + 1, flatBars + 1) : 0
    resTop = float(na)
    resBottom = float(na)
    resStart = int(na)
    supTop = float(na)
    supBottom = float(na)
    supStart = int(na)
    if mode == "Pivots"
        if not na(ph)
            current = PivotPoint.new(bar_index - timeHorizon, time[timeHorizon], ph, open[timeHorizon], high[timeHorizon], low[timeHorizon], close[timeHorizon])
            [top, bottom, origin] = pairPivot(highs, current, true, tolerance)
            resTop := top
            resBottom := bottom
            resStart := origin
        if not na(pl)
            current = PivotPoint.new(bar_index - timeHorizon, time[timeHorizon], pl, open[timeHorizon], high[timeHorizon], low[timeHorizon], close[timeHorizon])
            [top, bottom, origin] = pairPivot(lows, current, false, tolerance)
            supTop := top
            supBottom := bottom
            supStart := origin
    else if not na(volatility)
        depth = math.max(volatility * donchDepth, syminfo.mintick)
        if highFlat == flatBars
            resTop := math.round_to_mintick(channelHigh)
            resBottom := math.round_to_mintick(channelHigh - depth)
            resStart := time[flatBars]
        if lowFlat == flatBars
            supTop := math.round_to_mintick(channelLow + depth)
            supBottom := math.round_to_mintick(channelLow)
            supStart := time[flatBars]
    [resTop, resBottom, resStart, not na(resStart), supTop, supBottom, supStart, not na(supStart), time]

confirmedSourceZones() =>
    [resTop, resBottom, resStart, resFound, supTop, supBottom, supStart, supFound, stamp] = sourceZones()
    [resTop[1], resBottom[1], resStart[1], resFound[1], supTop[1], supBottom[1], supStart[1], supFound[1], stamp[1]]

// Overlap and Creation
rectsOverlap(aLeft, aRight, aTop, aBottom, bLeft, bRight, bTop, bBottom) =>
    timeOverlap = aLeft <= bRight and aRight >= bLeft
    priceOverlap = aBottom <= bTop and aTop >= bBottom
    timeOverlap and priceOverlap

isOverlapping(left, top, bottom) =>
    overlap = false
    if zones.size() > 0
        for i = 0 to zones.size() - 1
            item = zones.get(i)
            right = item.extending ? time : box.get_right(item.bx)
            if rectsOverlap(left, time, top, bottom, box.get_left(item.bx), right, item.top, item.bottom)
                overlap := true
                break
    overlap

newZone(top, bottom, origin, isSupport) =>
    created = false
    valid = not na(top) and not na(bottom) and not na(origin) and top > bottom
    if valid and zones.size() < 500
        intact = isSupport ? close >= bottom : close <= top
        if intact and not isOverlapping(origin, top, bottom)
            col = isSupport ? bullCol : bearCol
            bx = box.new(left = origin, top = top, right = time, bottom = bottom, xloc = xloc.bar_time, extend = extend.none, border_color = color.new(col, zoneBorderTransparency), border_width = 1, border_style = line.style_solid, bgcolor = color.new(col, zoneFillTransparency))
            mid = line.new(origin, math.avg(top, bottom), time, math.avg(top, bottom), xloc = xloc.bar_time, color = color.new(col, 40), style = line.style_dotted, width = 2)
            zones.push(Zone.new(bx = bx, mid = mid, lbl = na, top = top, bottom = bottom, isSupport = isSupport, born = bar_index))
            created := true
    created

// Statistics and Display Helpers
probability(isSupport, age) =>
    index = (isSupport ? 0 : expiryBars + 1) + math.max(0, math.min(age, expiryBars))
    samples = riskSamples.get(index)
    breaks = riskBreaks.get(index)
    value = samples >= minSamples ? 100.0 * breaks / samples : float(na)
    [value, samples]

percentText(value) =>
    na(value) ? "N/A" : str.tostring(value, "#.0") + "%"

priceText(value) =>
    na(value) ? "—" : str.tostring(value, format.mintick)

sourceText(simple string tf) =>
    seconds = timeframe.in_seconds(tf)
    seconds < 60 ? str.tostring(seconds) + "s" : seconds < 3600 ? str.tostring(seconds / 60, "#") + "m" : seconds < 86400 ? str.tostring(seconds / 3600, "#.#") + "H" : tf

clearMarker(value, above, gap) =>
    result = value
    if not na(result) and zones.size() > 0
        for pass = 0 to zones.size()
            moved = false
            for i = 0 to zones.size() - 1
                item = zones.get(i)
                present = box.get_left(item.bx) <= time and (item.extending or box.get_right(item.bx) >= time)
                if present and result >= item.bottom - gap * 0.5 and result <= item.top + gap * 0.5
                    result := above ? item.top + gap : item.bottom - gap
                    moved := true
            if not moved
                break
    result

writeCell(column, row, value, textCol, background) =>
    table.cell(dashboard, column, row, value, text_color = textCol, bgcolor = background, text_size = textSize, text_halign = column == 0 ? text.align_left : text.align_right)

// Timeframe Routing
chartSeconds = timeframe.in_seconds()
sourceSeconds = timeframe.in_seconds(zoneTF)
higherTF = useMTF and sourceSeconds > chartSeconds
if barstate.isfirst
    if useMTF and (na(chartSeconds) or na(sourceSeconds) or sourceSeconds < chartSeconds)
        runtime.error("The zone source must be the chart timeframe or a higher timeframe. Use a time-based chart for MTF detection.")
    if showDashboard
        table.merge_cells(dashboard, 0, 0, 2, 0)
        table.merge_cells(dashboard, 0, 1, 2, 1)
        table.merge_cells(dashboard, 0, 14, 2, 14)
        table.merge_cells(dashboard, 0, 15, 2, 15)

[resTop, resBottom, resStart, resFound, supTop, supBottom, supStart, supFound, sourceStamp] = if higherTF
    request.security(syminfo.tickerid, zoneTF, confirmedSourceZones(), gaps = barmerge.gaps_off, lookahead = barmerge.lookahead_on)
else
    sourceZones()

sourceTag = sourceText(higherTF ? zoneTF : timeframe.period)
chartAtrRaw = ta.atr(14)
chartAtr = math.max(nz(chartAtrRaw, high - low), syminfo.mintick)
markerGap = math.max(chartAtr * markerMult, syminfo.mintick * 3)

// Event State
newSupport = false
newResistance = false
supportTouch = false
resistanceTouch = false
bullRejection = false
bearRejection = false
bullSweep = false
bearSweep = false
bullBreak = false
bearBreak = false
supportExpired = false
resistanceExpired = false
nearExpiry = false
highRiskTouch = false
bullRejectY = float(na)
bearRejectY = float(na)
bullSweepY = float(na)
bearSweepY = float(na)
bullBreakY = float(na)
bearBreakY = float(na)

// Chart-Timeframe Zone Management
if zones.size() > 0
    for i = 0 to zones.size() - 1
        item = zones.get(i)
        if item.extending
            box.set_right(item.bx, time)
            if not na(item.mid)
                line.set_x2(item.mid, time)
            if barstate.isconfirmed
                age = bar_index - item.born
                remaining = math.max(0, expiryBars - age)
                if age > 0 and age <= expiryBars and na(item.breakAge)
                    touched = high >= item.bottom and low <= item.top
                    newVisit = touched and not item.wasTouch
                    if newVisit
                        item.tests += 1
                        item.visitOpen := true
                        item.rejectedVisit := false
                        item.sweptVisit := false
                        supportTouch := supportTouch or item.isSupport
                        resistanceTouch := resistanceTouch or not item.isSupport
                    if touched
                        item.touchVolume += nz(volume)
                    closedThrough = item.isSupport ? close < item.bottom : close > item.top
                    if closedThrough
                        item.breakAge := age
                        if item.isSupport
                            bearBreak := true
                            y = math.min(low, item.bottom) - markerGap
                            bearBreakY := na(bearBreakY) ? y : math.min(bearBreakY, y)
                        else
                            bullBreak := true
                            y = math.max(high, item.top) + markerGap
                            bullBreakY := na(bullBreakY) ? y : math.max(bullBreakY, y)
                    else
                        safeClose = item.isSupport ? close > item.top : close < item.bottom
                        wickThrough = touched and (item.isSupport ? low < item.bottom and math.min(open, close) >= item.bottom : high > item.top and math.max(open, close) <= item.top)
                        swept = wickThrough and not item.sweptVisit
                        rejected = item.visitOpen and safeClose and not item.rejectedVisit
                        if swept
                            item.sweeps += 1
                            item.sweptVisit := true
                            if item.isSupport
                                bullSweep := true
                                y = math.min(low, item.bottom) - markerGap
                                bullSweepY := na(bullSweepY) ? y : math.min(bullSweepY, y)
                            else
                                bearSweep := true
                                y = math.max(high, item.top) + markerGap
                                bearSweepY := na(bearSweepY) ? y : math.max(bearSweepY, y)
                        if rejected
                            item.rejects += 1
                            item.rejectedVisit := true
                            if item.isSupport
                                bullRejection := true
                                if not swept or not showSignals
                                    y = math.min(low, item.bottom) - markerGap
                                    bullRejectY := na(bullRejectY) ? y : math.min(bullRejectY, y)
                            else
                                bearRejection := true
                                if not swept or not showSignals
                                    y = math.max(high, item.top) + markerGap
                                    bearRejectY := na(bearRejectY) ? y : math.max(bearRejectY, y)
                        if newVisit
                            [estimate, samples] = probability(item.isSupport, age)
                            highRiskTouch := highRiskTouch or (not na(estimate) and estimate >= highRiskThreshold)
                        if not touched
                            item.visitOpen := false
                            item.rejectedVisit := false
                            item.sweptVisit := false
                    item.wasTouch := touched
                expired = age >= expiryBars
                stopForBreak = stopOnBreak and not na(item.breakAge)
                if expired or stopForBreak
                    item.extending := false
                    box.set_right(item.bx, time)
                    box.set_border_style(item.bx, line.style_solid)
                    if not na(item.mid)
                        line.set_x2(item.mid, time)
                    if not na(item.lbl)
                        label.delete(item.lbl)
                        item.lbl := na
                    if expired
                        supportExpired := supportExpired or item.isSupport
                        resistanceExpired := resistanceExpired or not item.isSupport
                else if remaining <= expiryWarning and not item.warned
                    item.warned := true
                    nearExpiry := true

// Zone Admission
if barstate.isconfirmed and not na(sourceStamp) and (na(lastSourceStamp) or sourceStamp != lastSourceStamp)
    lastSourceStamp := sourceStamp
    if bar_index >= firstAllowedBar
        if supFound
            newSupport := newZone(supTop, supBottom, supStart, true)
        if resFound
            newResistance := newZone(resTop, resBottom, resStart, false)

// Completed-Cohort Probability
if barstate.isconfirmed
    while matureCursor < zones.size()
        item = zones.get(matureCursor)
        if bar_index - item.born < expiryBars
            break
        broken = not na(item.breakAge)
        finalAge = broken ? item.breakAge : expiryBars + 1
        lastIntactAge = math.min(expiryBars - 1, finalAge - 1)
        base = item.isSupport ? 0 : expiryBars + 1
        if lastIntactAge >= 0
            for age = 0 to lastIntactAge
                index = base + age
                riskSamples.set(index, riskSamples.get(index) + 1)
                if broken
                    riskBreaks.set(index, riskBreaks.get(index) + 1)
        matureCursor += 1

// Zone-Relative Markers
bullRejectY := clearMarker(showSignals ? bullRejectY : na, false, markerGap)
bearRejectY := clearMarker(showSignals ? bearRejectY : na, true, markerGap)
bullSweepY := clearMarker(showSignals ? bullSweepY : na, false, markerGap)
bearSweepY := clearMarker(showSignals ? bearSweepY : na, true, markerGap)
bullBreakY := clearMarker(showSignals ? bullBreakY : na, true, markerGap)
bearBreakY := clearMarker(showSignals ? bearBreakY : na, false, markerGap)

plotchar(bullRejectY, title = "Support Rejection", char = "▲", location = location.absolute, color = bullCol, size = size.tiny)
plotchar(bearRejectY, title = "Resistance Rejection", char = "▼", location = location.absolute, color = bearCol, size = size.tiny)
plotchar(bullSweepY, title = "Support Wick Sweep", char = "△", location = location.absolute, color = bullCol, size = size.tiny)
plotchar(bearSweepY, title = "Resistance Wick Sweep", char = "▽", location = location.absolute, color = bearCol, size = size.tiny)
plotchar(bullBreakY, title = "Resistance Close Through", char = "▲", location = location.absolute, color = bullCol, size = size.tiny)
plotchar(bearBreakY, title = "Support Close Through", char = "▼", location = location.absolute, color = bearCol, size = size.tiny)

// Active Labels and Dashboard
archiveFull = zones.size() >= 500
archiveFullEvent = barstate.isconfirmed and archiveFull and not archiveFull[1]

if barstate.islast
    extendingCounts = array.new_int(2, 0)
    intactCounts = array.new_int(2, 0)
    expiringCounts = array.new_int(2, 0)
    nearest = array.new_int(2, -1)
    distances = array.new_float(2, na)
    if zones.size() > 0
        for i = 0 to zones.size() - 1
            item = zones.get(i)
            if item.extending
                side = item.isSupport ? 0 : 1
                extendingCounts.set(side, extendingCounts.get(side) + 1)
                age = bar_index - item.born
                remaining = math.max(0, expiryBars - age)
                edge = item.isSupport ? item.bottom : item.top
                col = item.isSupport ? bullCol : bearCol
                broken = not na(item.breakAge)
                inside = close >= item.bottom and close <= item.top
                status = broken ? "BROKEN / EXTENDING" : inside ? "TESTING" : item.tests > 0 ? "TESTED" : "FRESH"
                if remaining <= expiryWarning
                    expiringCounts.set(side, expiringCounts.get(side) + 1)
                if not broken
                    intactCounts.set(side, intactCounts.get(side) + 1)
                    distance = math.max(0.0, math.max(item.bottom - close, close - item.top))
                    if na(distances.get(side)) or distance < distances.get(side)
                        distances.set(side, distance)
                        nearest.set(side, i)
                if showZoneLabels
                    [estimate, samples] = probability(item.isSupport, age)
                    estimateText = broken ? "Broken" : percentText(estimate)
                    titleText = (item.isSupport ? "SUPPORT" : "RESISTANCE") + " · " + sourceTag + " · " + status
                    detailText = "Edge " + priceText(edge) + " | Age " + str.tostring(age) + "b | Left " + str.tostring(remaining) + "b"
                    statsText = "Tests " + str.tostring(item.tests) + " | Rejects " + str.tostring(item.rejects) + " | Mit. " + estimateText
                    labelText = titleText + "\n" + detailText + "\n" + statsText
                    tip = "Range: " + priceText(item.bottom) + " to " + priceText(item.top) + "\nAge starts at admission, not at the back-drawn formation anchor.\nWick sweeps: " + str.tostring(item.sweeps) + "\nTouch-bar volume: " + str.tostring(item.touchVolume, format.volume) + " (whole bars, not volume at price).\nComparable completed zones: " + str.tostring(samples) + " (minimum 20).\nMit. estimates a future close-through before expiry, conditional on surviving to this age. It is not a calibrated forecast."
                    if na(item.lbl)
                        item.lbl := label.new(bar_index + labelOffset, math.avg(item.top, item.bottom), labelText, xloc = xloc.bar_index, yloc = yloc.price, style = label.style_label_left, color = color.new(col, 100), textcolor = col, size = size.tiny, tooltip = tip)
                    else
                        label.set_xy(item.lbl, bar_index + labelOffset, math.avg(item.top, item.bottom))
                        label.set_text(item.lbl, labelText)
                        label.set_tooltip(item.lbl, tip)
    if showDashboard
        headerBG = color.new(chart.fg_color, 93)
        normalBG = color.new(chart.bg_color, 5)
        muted = color.new(chart.fg_color, 35)
        writeCell(0, 0, "SUPPORT & RESISTANCE ULTRA", chart.fg_color, headerBG)
        writeCell(0, 1, mode + " · " + sourceTag + " source · " + str.tostring(expiryBars) + " chart-bar expiry", muted, normalBG)
        writeCell(0, 2, "ZONE DATA", muted, headerBG)
        writeCell(1, 2, "SUPPORT", bullCol, color.new(bullCol, 92))
        writeCell(2, 2, "RESISTANCE", bearCol, color.new(bearCol, 92))
        rowNames = array.from("Intact / Extending", "Nearest break edge", "Distance to zone / ATR", "Age / Bars left", "Tests / Rejections", "Wick sweeps", "Mit. before expiry", "Comparable sample", "All-age mitigation", "Completed zones", "Expiring soon")
        for row = 0 to rowNames.size() - 1
            writeCell(0, row + 3, rowNames.get(row), muted, normalBG)
        for side = 0 to 1
            col = side == 0 ? bullCol : bearCol
            column = side + 1
            [allRate, completed] = probability(side == 0, 0)
            writeCell(column, 3, str.tostring(intactCounts.get(side)) + " / " + str.tostring(extendingCounts.get(side)), col, normalBG)
            for row = 4 to 10
                writeCell(column, row, "—", muted, normalBG)
            index = nearest.get(side)
            if index >= 0
                item = zones.get(index)
                age = bar_index - item.born
                [estimate, samples] = probability(item.isSupport, age)
                writeCell(column, 4, priceText(item.isSupport ? item.bottom : item.top), col, normalBG)
                writeCell(column, 5, str.tostring(distances.get(side) / chartAtr, "#.00") + " ATR", chart.fg_color, normalBG)
                writeCell(column, 6, str.tostring(age) + " / " + str.tostring(math.max(0, expiryBars - age)), chart.fg_color, normalBG)
                writeCell(column, 7, str.tostring(item.tests) + " / " + str.tostring(item.rejects), chart.fg_color, normalBG)
                writeCell(column, 8, str.tostring(item.sweeps), chart.fg_color, normalBG)
                writeCell(column, 9, percentText(estimate), not na(estimate) and estimate >= highRiskThreshold ? bearCol : chart.fg_color, normalBG)
                writeCell(column, 10, str.tostring(samples) + (samples < minSamples ? " / " + str.tostring(minSamples) + " min" : ""), chart.fg_color, normalBG)
            writeCell(column, 11, percentText(allRate), chart.fg_color, normalBG)
            writeCell(column, 12, str.tostring(completed), chart.fg_color, normalBG)
            writeCell(column, 13, str.tostring(expiringCounts.get(side)), col, normalBG)
        archiveText = archiveFull ? "ARCHIVE FULL · 500/500 · New zones paused" : "Retained boxes: " + str.tostring(zones.size()) + "/500 · No box deletion"
        writeCell(0, 14, archiveText, archiveFull ? bearCol : muted, headerBG)
        writeCell(0, 15, "Historical estimate · Completed cohorts only", muted, normalBG)

// Alerts
alertcondition(newSupport, title = "New Support Zone")
alertcondition(newResistance, title = "New Resistance Zone")
alertcondition(newSupport or newResistance, title = "Any New Zone")
alertcondition(supportTouch, title = "Support Zone Touch")
alertcondition(resistanceTouch, title = "Resistance Zone Touch")
alertcondition(supportTouch or resistanceTouch, title = "Any Zone Touch")
alertcondition(bullRejection, title = "Bullish Support Rejection")
alertcondition(bearRejection, title = "Bearish Resistance Rejection")
alertcondition(bullRejection or bearRejection, title = "Any Zone Rejection")
alertcondition(bullSweep, title = "Bullish Support Wick Sweep")
alertcondition(bearSweep, title = "Bearish Resistance Wick Sweep")
alertcondition(bullSweep or bearSweep, title = "Any Zone Wick Sweep")
alertcondition(bullBreak, title = "Bullish Resistance Close Through")
alertcondition(bearBreak, title = "Bearish Support Close Through")
alertcondition(bullBreak or bearBreak, title = "Any Zone Close Through")
alertcondition(supportExpired, title = "Support Zone Expired")
alertcondition(resistanceExpired, title = "Resistance Zone Expired")
alertcondition(supportExpired or resistanceExpired, title = "Any Zone Expired")
alertcondition(nearExpiry, title = "Zone Near Expiry")
alertcondition(highRiskTouch, title = "High Mitigation Estimate Touch")
alertcondition(archiveFullEvent, title = "Zone Archive Full")
